进阶
编译原理
Token -> 语法树 AST -> 中间码 -> 机器码
-
词法与语法分析
-
词法分析的作用就是解析源代码文件,它将文件中的字符串序列转换成 Token 序列,方便后面的处理和解析
-
语法分析器会按照顺序解析 Token 序列,并转换成语法树
-
该过程会将词法分析生成的 Token 按照编程语言定义好的文法(Grammar)自下而上或者自上而下的规约,每一个 Go 的源代码文件最终会被归纳成一个 SourceFile 结构
-
SourceFile = PackageClause ";" { ImportDecl ";" } { TopLevelDecl ";" } .
-
-
词法分析会返回一个不包含空格、换行等字符的 Token 序列,例如:
package, json, import, (, io, ), …
,而语法分析会把 Token 序列转换成有意义的结构体,即语法树:"json.go": SourceFile {
PackageName: "json",
ImportDecl: []Import{
"io",
},
TopLevelDecl: ...
} -
语法解析的过程中发生的任何语法错误都会被语法解析器发现并将消息打印到标准输出上,整个编译过程也会随着错误的出现而被中止。
-
-
类型检查
-
Go 语言的编译器会对语法树中定义和使用的类型进行检查,类型检查会按照以下的顺序分别验证和处理不同类型的节点:
- 常量、类型和函数名及类型;
- 变量的赋值和初始化;
- 函数和闭包的主体;
- 哈希键值对的类型;
- 导入函数体;
- 外部的声明;
-
通过对整棵抽象语法树的遍历,我们在每个节点上都会对当前子树的类型进行验证,以保证节点不存在类型错误,所有的类型错误和不匹配都会在这一个阶段被暴露出来,其中包括:结构体对接口的实现。
类型检查阶段不止会对节点的类型进行验证,还会展开和改写一些内建的函数,例如 make 关键字在这个阶段会根据子树的结构被替换成
runtime.makeslice
或者runtime.makechan
等函数。
-
-
中间代码生成
- 当我们将源文件转换成了抽象语法树、对整棵树的语法进行解析并进行类型检查之后,就可以认为当前文件中的代码不存在语法错误和类型错误的问题了,Go 语言的编译器就会将输入的抽象语法树转换成中间代码。
- 在类型检查之后,编译器会通过
cmd/compile/internal/gc.compileFunctions
编译整个 Go 语言项目中的全部函数,这些函数会在一个编译队列中等待几个 Goroutine 的消费,并发执行的 Goroutine 会将所有函数对应的抽象语法树转换成中间代码。 - 由于 Go 语言编译 器的中间代码使用了 SSA 的特性,所以在这一阶段我们能够分析出代码中的无用变量和片段并对代码进行优化
-
机器码生成
- Go 语言源代码的
src/cmd/compile/internal
目录中包含了很多机器码生成相关的包,不同类型的 CPU 分别使用了不同的包生成机器码,其中包括 amd64、arm、arm64、mips、mips64、ppc64、s390x、x86 和 wasm
- Go 语言源代码的
数据结构
chan
环形队列、两个等待队列
src/runtime/chan.go:hchan
定义了 channel 的数据结构:
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即可以存放的元素个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 队列下标, 指示元素写入时存放到队列中的位置
recvx uint // 队列下标,指示元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex // 互斥锁,chan不允许并发读写
}
结构
环形队列
chan 内部实现了一个环形队列作为其缓冲区,队列的长度是创建 chan 时指定的。

dataqsiz
指示了队列长度为 6,即可缓存 6 个元素;buf
指向队列的内存,队列中还剩余两个元素;qcount
表示队列中还有两个元素;sendx
指示后续写入的数据存储的位置,取值[0, 6)
;recvx
指示从该位置读取数据, 取值[0, 6)
等待队列
从 channel 读数据,如果 channel 缓冲区为空或者没有缓冲区,当前 goroutine 会被阻塞。
向 channel 写数据,如果 channel 缓冲区已满或者没有缓冲区,当前 goroutine 会被阻塞。
被阻塞的 goroutine 将会挂在 channel 的等待队列中:
- 因读阻塞的 goroutine 会被向 channel 写入数据的 goroutine 唤醒;
- 因写阻塞的 goroutine 会被从 channel 读数据的 goroutine 唤醒;
下图展示了一个没有缓冲区的 channel,有几个 goroutine 阻塞等待读数据:

注意,一般情况下 recvq
和 sendq
至少有一个为空。只有一个例外,那就是同一个 goroutine 使用 select 语句向 channel 一边写数据,一边读数据(?
类型信息
一个 channel 只能传递一种类型的值,类型信息存储在 hchan
数据结构中。
elemtype
代表类型,用于数据传递过程中的赋值;elemsize
代表类型大小,用于在buf
中定位元素位置。
锁
channel 不支持并发读写,一个 channel 同时仅允许被一个 goroutine 读写,为简单起见,后续部分说明读写过程时不再涉及加锁和解锁。
channel 操作
创建
创建 channel 的过程实际上是初始化 hchan
结构。其中类型信息和缓冲区长度由 make
语句传入,buf
的大小则与元素大小和缓冲区长度共同决定。
创建 channel 的伪代码如下所示:
func makechan(t *chantype, size int) *hchan {
var c *hchan
c = new(hchan)
c.buf = malloc(元素类型大小*size)
c.elemsize = 元素类型大小
c.elemtype = 元素类型
c.dataqsiz = size
return c
}
向 channel 中写
向一个 channel 中写数据简单过程如下:
- 如果等待接收队列
recvq
不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq
取出 G,并把数据写入,最后把该 G 唤醒,结束发送过程; - 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
- 如果缓冲区中没有空余位置,将待发送数据写入 G,将当前 G 加入
sendq
,进入睡眠,等待被读 goroutine 唤醒;
简单流程图如下:

向 channel 中读
从一个 channel 读数据简单过程如下:
- 如果等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G,把 G 中数据读出,最后把 G 唤醒,结束读取过程;
- 如果等待发送队列 sendq 不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区 取出数据,结束读取过程;
- 将当前 goroutine 加入
recvq
,进入睡眠,等待被写 goroutine 唤醒;
简单流程图如下:

关闭
关闭 channel 时会把 recvq
中的 G 全部唤醒,本该写入 G 的数据位置为 nil。
把 sendq
中的 G 全部唤醒,但这些 G 会 panic。
除此之外,panic 出现的常见场景还有:
- 关闭值为 nil 的 channel
- 关闭已经被关闭的 channel
- 向已经关闭的 channel 写数据
常见用法
单项 channel
顾名思义,单向 channel 指只能用于发送或接收数据,实际上也没有单向 channel。
我们知道 channel 可以通过参数传递,所谓单向 channel 只是对 channel 的一种使用限制,这跟 C 语言使用 const 修饰函数参数为只读是一个道理。
func readChan(chanName <-chan int)
: 通过形参限定函数内部只能从 channel 中读取数据func writeChan(chanName chan<- int)
: 通过形参限定函数内部只能向 channel 中写入数据
一个简单的示例程序如下:
func readChan(chanName <-chan int) {
<- chanName
}
func writeChan(chanName chan<- int) {
chanName <- 1
}
func main() {
var mychan = make(chan int, 10)
writeChan(mychan)
readChan(mychan)
}
mychan 是个正常的 channel,而 readChan()
参数限制了传入的 channel 只能用来读,writeChan()
参数限制了传入的 channel 只能用来写。
select
使用 select 可以监控多 channel,比如监控多个 channel,当其中某一个 channel 有数据时,就从其读出数据。
一个简单的示例程序如下:
package main
import (
"fmt"
"time"
)
func addNumberToChan(chanName chan int) {
for {
chanName <- 1
time.Sleep(1 * time.Second)
}
}
func main() {
var chan1 = make(chan int, 10)
var chan2 = make(chan int, 10)
go addNumberToChan(chan1)
go addNumberToChan(chan2)
for {
select {
case e := <- chan1 :
fmt.Printf("Get element from chan1: %d\n", e)
case e := <- chan2 :
fmt.Printf("Get element from chan2: %d\n", e)
default:
fmt.Printf("No element in chan1 and chan2.\n")
time.Sleep(1 * time.Second)
}
}
}
程序中创建两个 channel: chan1 和 chan2。函数 addNumberToChan()
函数会向两个 channel 中周期性写入数据。通过 select 可以监控两个 channel,任意一个可读时就从其中读出数据。
程序输出如下:
D:\SourceCode\GoExpert\src>go run main.go
Get element from chan1: 1
Get element from chan2: 1
No element in chan1 and chan2.
Get element from chan2: 1
Get element from chan1: 1
No element in chan1 and chan2.
Get element from chan2: 1
Get element from chan1: 1
No element in chan1 and chan2.
从输出可见,从 channel 中读出数据的顺序是随机的,事实上 select 语句的多个 case 执行顺序是随机的。
range
通过 range 可以持续从 channel 中读出数据,好像在遍历一个数组一样,当 channel 中没有数据时会阻塞当前 goroutine,与读 channel 时阻塞处理机制一样。
func chanRange(chanName chan int) {
for e := range chanName {
fmt.Printf("Get element from chan: %d\n", e)
}
}
注意:如果向此 channel 写数据 的 goroutine 退出时,系统检测到这种情况后会 panic,否则 range 将会永久阻塞。
slice
Slice 依托数组实现,底层数组对用户屏蔽,在底层数组容量不足时可以实现自动重分配并生成新的 Slice。
源码包中 src/runtime/slice.go:slice
定义了 Slice 的数据结构:
type slice struct {
array unsafe.Pointer
len int
cap int
}
从数据结构看 Slice 很清晰,array 指针指向底层数组,len 表示切片长度,cap 表示底层数组容量。
使用 make 创建 Slice
使用 make 来创建 Slice 时,可以同时指定长度和容量,创建时底层会分配一个数组,数组的长度即容量。
例如,语句 slice := make([]int, 5, 10)
所创建的 Slice,结构如下图所示:
该 Slice 长度为 5,即可以使用下标 slice[0]
~ slice[4]
来操作里面的元素,capacity 为 10,表示后续向 slice 添加新的元素时可以不必重新分配内存,直接使用预留内存即可。
使用数组创建 Slice
使用数组来创建 Slice 时,Slice 将与原数组共用一部分内存。
例如,语句 slice := array[5:7]
所创建的 Slice,结构如下图所示:
切片从数组 array[5]
开始,到数组 array[7]
结束(不含 array[7]
),即切片长度为 2,数组后面的内容都作为切片的预留内存,即 capacity 为 5。
数组和切片操作可能作用于同一块内存,这也是使用过程中需要注意的地方。
Slice 扩容
使用 append 向 Slice 追加元素时,如果 Slice 空间不足,将会触发 Slice 扩容
扩容实际上重新一配一块更大的内存,将原 Slice 数据拷贝进新 Slice,然后返回新 Slice,扩容后再将数据追加进去。
例如,当向一个 capacity 为 5,且 length 也为 5 的 Slice 再次追加 1 个元素时,就会发生扩容,如下图所示:
扩容操作只关心容量,会把原 Slice 数据拷贝到新 Slice,追加数据由 append()
在扩容结束后完成。
上图可见,扩容后新的 Slice 长度仍然是 5,但容量由 5 提升到了 10,原 Slice 的数据也都拷贝到了新 Slice 指向的数组中。
⭐ 扩容容量的选择遵循以下规则:
-
当新 Slice 需要的容量大于原 Slice 容量的两倍,则直接按照新切片需要的容量扩容;
-
如果原 Slice 容量小于 1024,则新 Slice 容量将扩大为原来的 2 倍;
-
如果原 Slice 容量大于等于 1024,则新 Slice 容量将扩大为原来的 1.25 倍;
使用 append()
向 Slice 添加一个元素的实现步骤如下:
- 假如 Slice 容量够用,则将新元素追加进去,
Slice.len++
,返回原 Slice - 原 Slice 容量不够,则
- 将 Slice 先扩容,扩容后得到新 Slice
- 将新元素追加进新 Slice,
Slice.len++
,返回新的 Slice。
Slice Copy
使用 copy()
内置函数拷贝两个切片时,会将源切片的数据逐个拷贝到目的切片指向的数组中,拷贝数量取两个切片长度的最小值。
例如长度为 10 的切片拷贝到长度为 5 的切片时,将会拷贝 5 个元素。
也就是说,copy 过程中不会发生扩容。
特殊切片
跟据数组或切片生成新的切片一般使用 slice := array[start:end]
方式,这种新生成的切片并没有指定切片的容量,实际上新切片的容量是从 start 开始直至 array 的结束。
比如下面两个切片,长度和容量都是一致的,使用共同的内存地址:
sliceA := make([]int, 5, 10)
sliceB := sliceA[0:5]
根据数组或切片生成切片还有另一种写法,即切片同时也指定容量,即 slice[start:end:cap]
, 其中 cap 即为新切片的容量,当然容量不能超过原切片实际值,如下所示:
sliceA := make([]int, 5, 10) //length = 5; capacity = 10
sliceB := sliceA[0:5] //length = 5; capacity = 10
sliceC := sliceA[0:5:5] //length = 5; capacity = 5
这切片方法不常见,在 Golang 源码里能够见到,不过非常利于切片的理解。
Tips
- 创建切片时可跟据实际需要预分配容量